1use super::shared::{fetch_rawg_metadata, EnrichProgress};
13use crate::constants::{RAWG_RATE_LIMIT_MS, RAWG_REQUISITIONS_PER_BATCH};
14use crate::database;
15use crate::database::AppState;
16use crate::errors::AppError;
17use crate::services::{cache, playtime, steam};
18use crate::utils::series;
19use rusqlite::params;
20use std::collections::{HashMap, HashSet};
21use std::time::Duration;
22use tauri::{AppHandle, Emitter, Manager, State};
23use tokio::time::sleep;
24use tracing::{info, warn};
25
26#[derive(serde::Serialize)]
29pub struct ImportSummary {
30 pub success_count: i32,
31 pub error_count: i32,
32 pub total_processed: i32,
33 pub message: String,
34 pub errors: Vec<String>,
35}
36
37struct ProcessedGameDetails {
39 game_id: String,
40 description_raw: Option<String>,
41 description_ptbr: Option<String>,
42 release_date: Option<String>,
43 genres: String,
44 tags: Vec<crate::models::GameTag>,
45 developer: Option<String>,
46 publisher: Option<String>,
47 critic_score: Option<i32>,
48 background_image: Option<String>,
49 series: Option<String>,
50 steam_review_label: Option<String>,
51 steam_review_count: Option<i32>,
52 steam_review_score: Option<f32>,
53 steam_review_updated_at: Option<String>,
54 esrb_rating: Option<String>,
55 is_adult: bool,
56 adult_tags: Option<String>,
57 external_links: Option<String>,
58 steam_app_id: Option<String>,
59 median_playtime: Option<i32>,
60 estimated_playtime: Option<f32>,
61}
62
63fn extract_steam_id_from_url(url: &str) -> Option<String> {
66 if url.contains("store.steampowered.com/app/") {
67 let parts: Vec<&str> = url.split("/app/").collect();
68 if let Some(right_part) = parts.get(1) {
69 let id_part: String = right_part.chars().take_while(|c| c.is_numeric()).collect();
70 if !id_part.is_empty() {
71 return Some(id_part);
72 }
73 }
74 }
75 None
76}
77
78async fn fetch_steam_store_data(
82 steam_id: &str,
83 cache_conn: &rusqlite::Connection,
84) -> Option<steam::SteamStoreData> {
85 let cache_key = format!("store_{}", steam_id);
86
87 if let Some(cached) = cache::get_cached_api_data(cache_conn, "steam", &cache_key) {
88 if let Ok(data) = serde_json::from_str::<steam::SteamStoreData>(&cached) {
89 return Some(data);
90 }
91 }
92
93 match steam::get_app_details(steam_id).await {
94 Ok(Some(data)) => {
95 if let Ok(json) = serde_json::to_string(&data) {
96 let _ = cache::save_cached_api_data(cache_conn, "steam", &cache_key, &json);
97 }
98 Some(data)
99 }
100 _ => None,
101 }
102}
103
104async fn fetch_steam_reviews(
106 steam_id: &str,
107 cache_conn: &rusqlite::Connection,
108) -> Option<steam::SteamReviewSummary> {
109 let cache_key = format!("reviews_{}", steam_id);
110
111 if let Some(cached) = cache::get_cached_api_data(cache_conn, "steam", &cache_key) {
112 if let Ok(reviews) = serde_json::from_str::<steam::SteamReviewSummary>(&cached) {
113 return Some(reviews);
114 }
115 }
116
117 match steam::get_app_reviews(steam_id).await {
118 Ok(Some(reviews)) => {
119 if let Ok(json) = serde_json::to_string(&reviews) {
120 let _ = cache::save_cached_api_data(cache_conn, "steam", &cache_key, &json);
121 }
122 Some(reviews)
123 }
124 _ => None,
125 }
126}
127
128async fn fetch_steam_playtime(steam_id: &str, cache_conn: &rusqlite::Connection) -> Option<u32> {
130 let cache_key = format!("playtime_{}", steam_id);
131
132 if let Some(cached) = cache::get_cached_api_data(cache_conn, "steam", &cache_key) {
133 if let Ok(hours) = cached.parse::<u32>() {
134 return Some(hours);
135 }
136 }
137
138 match steam::get_median_playtime(steam_id).await {
139 Ok(Some(hours)) => {
140 let _ =
141 cache::save_cached_api_data(cache_conn, "steam", &cache_key, &hours.to_string());
142 Some(hours)
143 }
144 _ => None,
145 }
146}
147
148async fn enrich_game_metadata(
152 api_key: &str,
153 game_id: &str,
154 name: &str,
155 platform: &str,
156 platform_id: Option<String>,
157 cache_conn: &rusqlite::Connection,
158) -> (ProcessedGameDetails, Vec<String>) {
159 let series_name = series::infer_series(name);
160 let mut details = ProcessedGameDetails {
161 game_id: game_id.to_string(),
162 description_raw: None,
163 description_ptbr: None,
164 release_date: None,
165 genres: String::new(),
166 tags: Vec::new(),
167 developer: None,
168 publisher: None,
169 critic_score: None,
170 background_image: None,
171 series: series_name,
172 steam_review_label: None,
173 steam_review_count: None,
174 steam_review_score: None,
175 steam_review_updated_at: None,
176 esrb_rating: None,
177 is_adult: false,
178 adult_tags: None,
179 external_links: None,
180 steam_app_id: None,
181 median_playtime: None,
182 estimated_playtime: None,
183 };
184
185 let mut links_map: HashMap<String, String> = HashMap::new();
186 let mut found_raw_tags: Vec<String> = Vec::new();
187
188 let mut target_steam_id = if platform.to_lowercase() == "steam" {
190 platform_id
191 } else {
192 None
193 };
194
195 if let Some(rawg_det) = fetch_rawg_metadata(api_key, name, cache_conn).await {
197 found_raw_tags = rawg_det.tags.iter().map(|t| t.slug.clone()).collect();
198
199 let raw_tag_slugs: Vec<String> = rawg_det.tags.iter().map(|t| t.slug.clone()).collect();
200
201 details.description_raw = rawg_det.description_raw;
202 details.release_date = rawg_det.released;
203 details.genres = rawg_det
204 .genres
205 .iter()
206 .map(|g| g.name.clone())
207 .collect::<Vec<_>>()
208 .join(", ");
209 details.tags = crate::services::tags::classify_and_sort_tags(raw_tag_slugs, 10);
210 details.developer = rawg_det.developers.first().map(|d| d.name.clone());
211 details.publisher = rawg_det.publishers.first().map(|p| p.name.clone());
212 details.critic_score = rawg_det.metacritic;
213 details.background_image = rawg_det.background_image;
214 details.esrb_rating = rawg_det.esrb_rating.as_ref().map(|r| r.name.clone());
215
216 if let Some(url) = &rawg_det.website {
218 links_map.insert("website".to_string(), url.clone());
219 }
220 if let Some(url) = &rawg_det.reddit_url {
221 links_map.insert("reddit".to_string(), url.clone());
222 }
223 if let Some(url) = &rawg_det.metacritic_url {
224 links_map.insert("metacritic".to_string(), url.clone());
225 }
226 links_map.insert(
227 "rawg".to_string(),
228 format!("https://rawg.io/api/games/{}", rawg_det.id),
229 );
230
231 if target_steam_id.is_none() {
233 for store_data in &rawg_det.stores {
234 if store_data.store.slug == "steam" {
235 if let Some(extracted_id) = extract_steam_id_from_url(&store_data.url) {
236 target_steam_id = Some(extracted_id);
237 links_map.insert("steam".to_string(), store_data.url.clone());
238 }
239 }
240 }
241 }
242 }
243
244 if let Some(steam_id) = &target_steam_id {
246 if !links_map.contains_key("steam") {
247 links_map.insert(
248 "steam".to_string(),
249 format!("https://store.steampowered.com/app/{}", steam_id),
250 );
251 }
252 details.steam_app_id = Some(steam_id.clone());
253
254 if let Some(store_data) = fetch_steam_store_data(steam_id, cache_conn).await {
256 let (detected_adult, flags) = steam::detect_adult_content(&store_data);
257 details.is_adult = detected_adult;
258 if !flags.is_empty() {
259 details.adult_tags = serde_json::to_string(&flags).ok();
260 }
261
262 if details.description_raw.is_none() {
264 details.description_raw = Some(store_data.short_description);
265 }
266 if details.release_date.is_none() {
267 details.release_date = store_data.release_date;
268 }
269 if details.background_image.is_none() {
270 details.background_image = Some(store_data.header_image);
271 }
272 }
273
274 if let Some(reviews) = fetch_steam_reviews(steam_id, cache_conn).await {
276 details.steam_review_label = Some(reviews.review_score_desc);
277 details.steam_review_count = Some(reviews.total_reviews as i32);
278 let total = reviews.total_positive + reviews.total_negative;
279 if total > 0 {
280 details.steam_review_score =
281 Some((reviews.total_positive as f32 / total as f32) * 100.0);
282 }
283 details.steam_review_updated_at = Some(chrono::Utc::now().to_rfc3339());
284 }
285
286 if let Some(hours) = fetch_steam_playtime(steam_id, cache_conn).await {
288 details.median_playtime = Some(hours as i32);
289
290 let genre_list: Vec<String> = details
291 .genres
292 .split(',')
293 .map(|s| s.trim().to_lowercase())
294 .collect();
295
296 if let Some(estimated_hours) =
297 playtime::estimate_playtime(Some(hours), &genre_list, &details.tags)
298 {
299 details.estimated_playtime = Some(estimated_hours as f32);
300 }
301 }
302 }
303
304 if !links_map.is_empty() {
305 details.external_links = serde_json::to_string(&links_map).ok();
306 }
307
308 (details, found_raw_tags)
309}
310
311fn save_game_details(
314 conn: &rusqlite::Connection,
315 d: ProcessedGameDetails,
316) -> Result<(), rusqlite::Error> {
317 let tags_json = database::serialize_tags(&d.tags).unwrap_or_else(|_| "[]".to_string());
318
319 conn.execute(
320 "INSERT OR REPLACE INTO game_details (
321 game_id, description_raw, description_ptbr, release_date, genres, tags,
322 developer, publisher, critic_score, background_image, series,
323 steam_review_label, steam_review_count, steam_review_score, steam_review_updated_at,
324 esrb_rating, is_adult, adult_tags, external_links, steam_app_id, median_playtime,
325 estimated_playtime
326 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)",
327 params![
328 d.game_id, d.description_raw, d.description_ptbr, d.release_date, d.genres, tags_json,
329 d.developer, d.publisher, d.critic_score, d.background_image, d.series,
330 d.steam_review_label, d.steam_review_count, d.steam_review_score, d.steam_review_updated_at,
331 d.esrb_rating, d.is_adult, d.adult_tags, d.external_links, d.steam_app_id, d.median_playtime,
332 d.estimated_playtime
333 ],
334 )?;
335
336 if let Some(img) = d.background_image {
337 conn.execute(
338 "UPDATE games SET cover_url = ?1 WHERE id = ?2 AND (cover_url IS NULL OR cover_url = '')",
339 params![img, d.game_id],
340 )?;
341 }
342
343 Ok(())
344}
345
346#[tauri::command]
350pub async fn update_metadata(app: AppHandle) -> Result<(), AppError> {
351 let app_handle = app.clone();
352 let api_key = database::get_secret(&app, "rawg_api_key")?;
353 if api_key.is_empty() {
354 return Err(AppError::ValidationError(
355 "API Key da RAWG não configurada.".to_string(),
356 ));
357 }
358
359 tauri::async_runtime::spawn(async move {
360 info!("Iniciando enriquecimento com cache...");
361
362 let state: State<AppState> = app_handle.state();
363 let mut all_session_tags: HashSet<String> = HashSet::new();
364
365 {
367 let cache_conn = state.metadata_db.lock().unwrap();
368 let _ = cache::cleanup_expired_cache(&cache_conn);
369 }
370
371 loop {
372 let games_to_update: Vec<(String, String, String, Option<String>)> = {
374 let conn = match state.library_db.lock() {
375 Ok(c) => c,
376 Err(_) => break,
377 };
378 let mut stmt = conn
379 .prepare(
380 "SELECT g.id, g.name, g.platform, g.platform_id
381 FROM games g
382 LEFT JOIN game_details gd ON g.id = gd.game_id
383 WHERE gd.game_id IS NULL
384 LIMIT ?",
385 )
386 .unwrap();
387
388 stmt.query_map(params![RAWG_REQUISITIONS_PER_BATCH], |row| {
389 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
390 })
391 .unwrap()
392 .flatten()
393 .collect()
394 };
395
396 if games_to_update.is_empty() {
397 break;
398 }
399
400 let total_in_batch = games_to_update.len();
401
402 for (index, (game_id, name, platform, platform_id)) in
404 games_to_update.into_iter().enumerate()
405 {
406 let _ = app_handle.emit(
407 "enrich_progress",
408 EnrichProgress {
409 current: (index + 1) as i32,
410 total_found: total_in_batch as i32,
411 last_game: name.clone(),
412 status: "running".to_string(),
413 },
414 );
415
416 let (processed_data, raw_tags) = {
418 let cache_conn = match state.metadata_db.lock() {
419 Ok(c) => c,
420 Err(_) => continue,
421 };
422
423 let result = tokio::task::block_in_place(|| {
425 let rt = tokio::runtime::Handle::current();
426 rt.block_on(async {
427 enrich_game_metadata(
428 &api_key,
429 &game_id,
430 &name,
431 &platform,
432 platform_id.clone(),
433 &cache_conn,
434 )
435 .await
436 })
437 });
438 result
439 };
440
441 for tag in raw_tags {
442 all_session_tags.insert(tag);
443 }
444
445 {
446 if let Ok(conn) = state.library_db.lock() {
447 if let Err(e) = save_game_details(&conn, processed_data) {
448 warn!("Erro ao salvar metadados para {}: {}", name, e);
449 }
450 }
451 }
452 }
453
454 sleep(Duration::from_millis(RAWG_RATE_LIMIT_MS)).await;
456 }
457
458 let _ = crate::services::tags::generate_analysis_report(&app_handle, all_session_tags);
459 let _ = app_handle.emit("enrich_complete", "Metadados atualizados!");
460 });
461
462 Ok(())
463}